iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Build on Google AI

給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手系列 第 15 篇

Day 15|一目瞭然的遠端關懷!用 Tailwind CSS 與 Chart.js 打造 Web 服藥追蹤儀表板

  • 分享至 

  • xImage
  •  

✏️【本日實作紀錄:Web 關懷儀表板實作與數據視覺化】

今天進入前端視覺化與家屬關懷儀表板開發階段。在前 14 天的實作中,我們已經建立了完整且強大的後端微服務與 SQLite 資料庫(含藥單明細與服藥打卡紀錄)。今天我們將使用 HTML5、Tailwind CSS 與 Chart.js,在 Flask 中打造輕量且質感極高的 Web 關懷儀表板。完成項目包含:

  • 實作儀表板 Flask 路由(/dashboard)與靜態範本 — 在 Flask 中整合 render_template,渲染獨立的 Web 管理介面。
  • 整合 Tailwind CSS CDN 與 responsive 版面 — 打造適應手機與電腦螢幕的高顏值卡片式 UI。
  • 動態 Fetch 後端 API(/prescriptions 與 /intake-logs) — 透過前端 JavaScript 自動抓取歷史用藥清單、警語與打卡紀錄。
  • 嵌入 Chart.js 視覺化服藥順從度圖表 — 提供家屬直觀的用藥統計與安全警示看板。

一、 為什麼需要 Web 關懷儀表板?

雖然 LINE Bot 提供了即時推播與便捷的打卡體驗,但對於需要長期關懷長輩用藥狀況的家屬或社區照護員來說,對話視窗中的訊息容易被洗版。

擁有一個網頁版的 Web 關懷儀表板有以下核心優勢:

  • 資訊集中呈現:家屬能在單一頁面上清楚看到長輩最新的藥單明細、白話摘要與語音播放器。
  • 視覺化數據追蹤:透過圓餅圖或柱狀圖,直觀掌握長輩本週的服藥順從率(Answering rate / Compliance rate)。
  • 安全警報顯眼標示:若 Gemini 檢測出高風險重複藥性,儀表板會以紅色警示卡片突出呈現,方便照護者追蹤。

二、 新增服藥紀錄 API 端點(app.py)

為了讓 Web 儀表板能抓取到長輩在 LINE 上打卡的歷史紀錄並渲染 HTML 網頁,我們需要在 app.py 中新增匯入項目與 API 路由。

請開啟 app.py:

1.讀者若沒有引入 render_template 與 Limiter,執行時會跳出 NameError。請將該區塊更新為:

# 請確認 app.py 頂部包含正確的 Flask 與 Limiter 匯入項目
from flask import Flask, request, jsonify, send_from_directory, render_template
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address

2.新增路由處理邏輯:

@app.route('/intake-logs', methods=['GET'])
def get_intake_logs():
    """提供 Web 儀表板抓取長輩歷史服藥打卡紀錄 API"""
    try:
        conn = sqlite3.connect(DATABASE_PATH)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        cursor.execute("SELECT * FROM intake_logs ORDER BY taken_at DESC LIMIT 50")
        logs = [dict(row) for row in cursor.fetchall()]

        conn.close()
        return jsonify({
            "status": "success",
            "data": logs
        }), 200

    except Exception as e:
        logger.error(f"查詢服藥紀錄失敗: {str(e)}")
        return jsonify({"error": f"查詢失敗: {str(e)}"}), 500

@app.route('/dashboard', methods=['GET'])
def dashboard():
    """Web 關懷儀表板頁面路由"""
    return render_template('dashboard.html')

三、打造高質感 HTML5/Tailwind 儀表板(templates/dashboard.html)

在專案根目錄下新建 templates 資料夾,並於其中建立 dashboard.html 檔案:

<!DOCTYPE html>
<html lang="zh-TW">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>PrescriptionVLM - 高齡用藥遠端關懷儀表板</title>
    <!-- 引入 Tailwind CSS CDN -->
    <script src="https://cdn.tailwindcss.com"></script>
    <!-- 引入 Chart.js CDN -->
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
</head>
<body class="bg-slate-50 text-slate-800 min-h-screen">

    <!-- 頂部導覽列 -->
    <header class="bg-indigo-600 text-white shadow-lg py-4 px-6 mb-8">
        <div class="max-w-7xl mx-auto flex justify-between items-center">
            <h1 class="text-2xl font-bold flex items-center gap-2">
                <span>💊</span> PrescriptionVLM 關懷儀表板
            </h1>
            <span class="bg-indigo-500 text-xs px-3 py-1 rounded-full">系統運作中 (Online)</span>
        </div>
    </header>

    <!-- 主要內容區 -->
    <main class="max-w-7xl mx-auto px-4 grid grid-cols-1 lg:grid-cols-3 gap-8 mb-12">
        
        <!-- 左側:歷史藥單與語音播放 (佔 2 欄) -->
        <section class="lg:col-span-2 space-y-6">
            <h2 class="text-xl font-bold text-slate-700 flex items-center gap-2">
                📋 最新解析藥單紀錄
            </h2>
            
            <div id="prescription-list" class="space-y-4">
                <p class="text-slate-400">正在載入用藥紀錄...</p>
            </div>
        </section>

        <!-- 右側:服藥打卡數據與圖表 (佔 1 欄) -->
        <section class="space-y-6">
            <h2 class="text-xl font-bold text-slate-700 flex items-center gap-2">
                📊 服藥狀態與順從度
            </h2>

            <!-- 服藥打卡近況卡片 -->
            <div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
                <h3 class="font-semibold text-slate-600 mb-4">近期 LINE 打卡動態</h3>
                <div id="intake-log-list" class="space-y-3 max-h-60 overflow-y-auto pr-2 text-sm">
                    <p class="text-slate-400">載入打卡紀錄中...</p>
                </div>
            </div>

            <!-- 圖表卡片 -->
            <div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100">
                <h3 class="font-semibold text-slate-600 mb-4">本週服藥率統計</h3>
                <canvas id="complianceChart" class="w-full"></canvas>
            </div>
        </section>

    </main>

    <!-- 前端動態渲染腳本 -->
    <script>
        async function fetchDashboardData() {
            try {
                // 1. 抓取藥單紀錄
                const resPrescription = await fetch('/prescriptions');
                const resultPrescription = await resPrescription.json();
                
                if (resultPrescription.status === 'success') {
                    renderPrescriptions(resultPrescription.data);
                }

                // 2. 抓取打卡紀錄
                const resLogs = await fetch('/intake-logs');
                const resultLogs = await resLogs.json();
                
                if (resultLogs.status === 'success') {
                    renderIntakeLogs(resultLogs.data);
                }
            } catch (err) {
                console.error("載入儀表板資料失敗:", err);
            }
        }

        function renderPrescriptions(data) {
            const container = document.getElementById('prescription-list');
            if (!data || data.length === 0) {
                container.innerHTML = '<div class="bg-white p-6 rounded-2xl text-slate-400">尚無歷史藥單紀錄</div>';
                return;
            }

            container.innerHTML = data.map(item => `
                <div class="bg-white p-6 rounded-2xl shadow-sm border border-slate-100 space-y-4">
                    <div class="flex justify-between items-start">
                        <span class="text-xs text-indigo-600 font-bold bg-indigo-50 px-2.5 py-1 rounded-md">ID: ${item.id}</span>
                        <span class="text-xs text-slate-400">${item.created_at}</span>
                    </div>
                    
                    ${item.safety_warnings && item.safety_warnings.length > 0 ? `
                        <div class="bg-rose-50 border-l-4 border-rose-500 p-3 rounded-r-md">
                            <p class="text-xs font-bold text-rose-700 flex items-center gap-1">⚠️ 用藥安全警示:</p>
                            <p class="text-xs text-rose-600 mt-1">${item.safety_warnings.join(';')}</p>
                        </div>
                    ` : ''}

                    <div>
                        <h4 class="text-sm font-semibold text-slate-500 mb-1">📢 白話文播報摘要:</h4>
                        <p class="text-slate-700 text-sm bg-slate-50 p-3 rounded-xl">${item.spoken_summary}</p>
                    </div>

                    <div>
                        <h4 class="text-sm font-semibold text-slate-500 mb-1">🎧 語音檔播放:</h4>
                        <audio controls class="w-full h-8">
                            <source src="${item.audio_url}" type="audio/mpeg">
                            您的瀏覽器不支援音訊播放。
                        </audio>
                    </div>

                    <div class="pt-2 border-t border-slate-100">
                        <h4 class="text-xs font-bold text-slate-400 mb-2">藥品明細 (${item.medicines.length} 種)</h4>
                        <div class="grid grid-cols-1 md:grid-cols-2 gap-2">
                            ${item.medicines.map(m => `
                                <div class="bg-slate-50 p-2.5 rounded-lg text-xs space-y-1">
                                    <p class="font-bold text-slate-800">${m.name} <span class="text-indigo-500">(${m.type})</span></p>
                                    <p class="text-slate-500">${m.frequency} | 劑量:${m.dosage} ${m.timing}</p>
                                </div>
                            `).join('')}
                        </div>
                    </div>
                </div>
            `).join('');
        }

        function renderIntakeLogs(data) {
            const container = document.getElementById('intake-log-list');
            if (!data || data.length === 0) {
                container.innerHTML = '<p class="text-slate-400">尚無打卡紀錄</p>';
                return;
            }

            container.innerHTML = data.map(log => `
                <div class="flex items-center justify-between p-2 rounded-lg bg-slate-50">
                    <span class="text-xs font-semibold text-emerald-600 flex items-center gap-1">
                        ✅ 已完成服藥
                    </span>
                    <span class="text-xs text-slate-400">${log.taken_at}</span>
                </div>
            `).join('');

            // 初始化 Chart.js 順從度圖表
            renderChart(data.length);
        }

        function renderChart(takenCount) {
            const ctx = document.getElementById('complianceChart').getContext('2d');
            new Chart(ctx, {
                type: 'doughnut',
                data: {
                    labels: ['按時服藥', '未紀錄/漏吃'],
                    datasets: [{
                        data: [takenCount, Math.max(0, 10 - takenCount)],
                        backgroundColor: ['#10b981', '#f1f5f9'],
                        borderWidth: 0
                    }]
                },
                options: {
                    responsive: true,
                    plugins: {
                        legend: { position: 'bottom' }
                    }
                }
            });
        }

        // 初始化載入資料
        fetchDashboardData();
    </script>
</body>
</html>

四、 測試與成果驗證

在 Docker 指令之前,提醒讀者需要先安裝 flask-limiter,以及重啟 Docker 容器時一定要先刪除舊容器(-f 參數),避免 Port 衝突或容器連線拒絕:

若本地開發環境尚未安裝流量限制套件,請先執行安裝:

pip install flask-limiter

更新 app.py 與新增 templates/dashboard.html 後,重新構建並啟動 Docker 容器(先刪除同名舊容器):

# 1. 刪除同名舊容器(避免名稱衝突導致新容器閃退或連線拒絕)
docker rm -f prescription_service

# 2. 重新構建 Docker 鏡像
docker build -t prescription-vlm:v1.0 .

# 3. 背景執行新容器並綁定 Port 5000
docker run -d -p 5000:5000 --env-file .env --name prescription_service prescription-vlm:v1.0

2.瀏覽器開啟 Web 儀表板
開啟瀏覽器造訪:http://127.0.0.1:5000/dashboard

預期畫面成果:

  • 頂部導覽列:顯示紫底白字的 PrescriptionVLM 關懷儀表板 與 Online 系統運作狀態。
  • 歷史紀錄區:頁面自動載入過去掃描過的所有藥袋,並附帶音訊播放器可直接線上播放 .mp3 語音檔。
  • 安全警示標籤:若有潛在重複藥性風險,頂部會醒目以紅底白字卡片提示家屬。
  • LINE 服藥打卡區與圓餅圖:右側同步顯示長輩在 LINE 聊天室回覆「已吃藥」的時間戳記,並渲染出 Chart.js 服藥順從率圖表。

https://ithelp.ithome.com.tw/upload/images/20260921/20183730a4HK4QTIJ6.png

五、版本控制與提交 GitHub

測試成功後,提交 Day 15 的程式碼:

git add .
git commit -m "保留雙引號 改填寫自己要記錄的標記 ex.鐵人賽第十五天"
git push

六、本日小結與明日預告

今天我們成功建立了第一個全功能的 Web 關懷儀表板,將多模態 VLM 解析成果、語音播報與 LINE 服藥打卡數據徹底整合視覺化,實現了「長輩聽得懂、家屬看的見」的完整系統體驗。

明天(Day 16),我們將開始進入 長者端無障礙介面與互動優化,探討如何針對銀髮族設計更大字體、更高對比以及語音互動反饋的對話介面體驗!


上一篇
Day 14|LINE 自動回覆會打卡!用 Webhook 串接與 ngrok 實現長輩服藥雙向追蹤閉環
下一篇
Day 16|長輩看得到也聽得懂!高齡無障礙 UI/UX 與語音互動優化
系列文
給藥袋裝一張嘴:30 天用 Android 與 Google VLM 實作高齡語音用藥助手 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言